Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: support editable install with multiple roots #126

Merged
merged 2 commits into from
Jan 18, 2023

Conversation

gilfree
Copy link
Contributor

@gilfree gilfree commented Jan 17, 2023

Python packages may have multiple root modules where each is installed to its own folder under site-packages.

When using new setuptools editable install in such cases griffe, when used by mkdocstrings fails to find the correct paths.

The root cause it that griffe assumed a variable named MAPPING in the .py file created by the editable install, and assumed that this variable is a dict with single entry.

When a package has multiple roots - then this dictionary contain multiple entries.

This PR aims to handle such cases.

I have no knowledge about editables so I could not fix that case.

The unit test added simulates the case, and this has been tested against a real complex package with multiple roots using setuptools 65 editable install.

@gilfree
Copy link
Contributor Author

gilfree commented Jan 17, 2023

Hi. 😄

My first attempt in PR here.
I hope I was able to properly follow the contributing guide.
If there is something that needs to be changed - let me know.

Copy link
Member

@pawamoy pawamoy left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, that's great! You did very well for your first contribution here 🚀

I have a few suggestions below 🙂

src/griffe/finder.py Outdated Show resolved Hide resolved
src/griffe/finder.py Outdated Show resolved Hide resolved
tests/test_finder.py Outdated Show resolved Hide resolved
tests/test_finder.py Outdated Show resolved Hide resolved
tests/test_finder.py Outdated Show resolved Hide resolved
tests/test_finder.py Outdated Show resolved Hide resolved
Comment on lines 359 to 362
if line.startswith("MAPPING"):
paths = ast.literal_eval(line.split(" = ", 1)[1])
assert isinstance(paths, dict)
return [Path(pt).parent for pt in paths.values()]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

At this point I feel like we should parse the entire file with AST (without any evaluation), and iterate on the module's body to find the MAPPING assignment node. Who knows if setuptools won't split the assignment on multiple lines in some cases, or in the future?

It would look like this:

parsed_module = ast.parse(path.read_text())
for node in parsed_module.body:
    if isinstance(node, ast.Assign) and node.targets[0].id == "MAPPING":
        return [Path(constant.value).parent for constant in node.value.values]

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have changed to full AST parsing, I agree it will be more stable.

A concern I do have at this point is that the change by setuptools or editables or any other package will be bigger than that.

It is probably not for this MR, and I don't understand the ModuleFinder mechanism enough, but I think it might probably be augmented by using the sys.meta_hook, and in that case there is no need to specially handle each editable install variant.

If you prefer, I think the code below may replace the _extend_from_editable_modules

    def find_package(self, module_name: str) -> Package | NamespacePackage:  # noqa: WPS231
        """Find a package or namespace package.

        Parameters:
            module_name: The module name.

        Raises:
            ModuleNotFoundError: When the module cannot be found.

        Returns:
            A package or namespace package wrapper.
        """
        filepaths = [
            Path(module_name),
            # TODO: handle .py[cod] and .so files?
            Path(f"{module_name}.py"),
        ]
       # Add meta path
        specs_paths = []
        for finder in sys.meta_path:
            spec = finder.find_spec(module_name,None)
            if spec and spec.submodule_search_locations:
                specs_paths.extend(Path(path).parent for path in spec.submodule_search_locations)
        namespace_dirs = []
        for path in self.search_paths + specs_paths:  # noqa: WPS440
            path_contents = self._contents(path)
            if path_contents:
                for choice in filepaths:
                    abs_path = path / choice
                    if abs_path in path_contents:
                        if abs_path.suffix:
                            stubs = abs_path.with_suffix(".pyi")
                            return Package(module_name, abs_path, stubs if stubs.exists() else None)
                        else:
                            init_module = abs_path / "__init__.py"
                            if init_module.exists() and not _is_pkg_style_namespace(init_module):
                                stubs = init_module.with_suffix(".pyi")
                                return Package(module_name, init_module, stubs if stubs.exists() else None)
                            namespace_dirs.append(abs_path)

        if namespace_dirs:
            return NamespacePackage(module_name, namespace_dirs)

        raise ModuleNotFoundError(module_name)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks! For reference, where did you get that code from?

I am indeed thinking about replacing Griffe's whole package finding algorithm with a subclass of what the standard lib offers, but just like you, I don't understand it enough and was not able to conclude if it would fit Griffe's needs or not. But I'm definitely open to suggestions as that would drastically simplify the code base.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a small change on the griffe code - just added the lines below (which I wrote myself) to the function from griffe, and iterated also on specs_paths

        specs_paths = []
        for finder in sys.meta_path:
            spec = finder.find_spec(module_name,None)
            if spec and spec.submodule_search_locations:
                specs_paths.extend(Path(path).parent for path in spec.submodule_search_locations)

Python packages may have multiple root modules where
each is installed to its own folder under `site-packages`.

When using new setuptools editable install in such cases
griffe, when used by mkdocstrings fails to find the
correct paths.

The root cause it that griffe assumed a variable named
`MAPPING` in the `.py` file created by the editable install,
and assumed that this variable is a dict with single entry.

When a package has multiple roots - then this dictionary
contain multiple entries.

This PR aims to handle such cases.

I have no knowledge about `editables` so I could not fix
that case.

The unit test added simulates the case, and this has been tested
against a real complex package with multiple roots using setuptools
65 editable install.
Copy link
Contributor Author

@gilfree gilfree left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have tried to fix according to your comments.
Thanks.

src/griffe/finder.py Outdated Show resolved Hide resolved
tests/test_finder.py Outdated Show resolved Hide resolved
Comment on lines 359 to 362
if line.startswith("MAPPING"):
paths = ast.literal_eval(line.split(" = ", 1)[1])
assert isinstance(paths, dict)
return [Path(pt).parent for pt in paths.values()]
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have changed to full AST parsing, I agree it will be more stable.

A concern I do have at this point is that the change by setuptools or editables or any other package will be bigger than that.

It is probably not for this MR, and I don't understand the ModuleFinder mechanism enough, but I think it might probably be augmented by using the sys.meta_hook, and in that case there is no need to specially handle each editable install variant.

If you prefer, I think the code below may replace the _extend_from_editable_modules

    def find_package(self, module_name: str) -> Package | NamespacePackage:  # noqa: WPS231
        """Find a package or namespace package.

        Parameters:
            module_name: The module name.

        Raises:
            ModuleNotFoundError: When the module cannot be found.

        Returns:
            A package or namespace package wrapper.
        """
        filepaths = [
            Path(module_name),
            # TODO: handle .py[cod] and .so files?
            Path(f"{module_name}.py"),
        ]
       # Add meta path
        specs_paths = []
        for finder in sys.meta_path:
            spec = finder.find_spec(module_name,None)
            if spec and spec.submodule_search_locations:
                specs_paths.extend(Path(path).parent for path in spec.submodule_search_locations)
        namespace_dirs = []
        for path in self.search_paths + specs_paths:  # noqa: WPS440
            path_contents = self._contents(path)
            if path_contents:
                for choice in filepaths:
                    abs_path = path / choice
                    if abs_path in path_contents:
                        if abs_path.suffix:
                            stubs = abs_path.with_suffix(".pyi")
                            return Package(module_name, abs_path, stubs if stubs.exists() else None)
                        else:
                            init_module = abs_path / "__init__.py"
                            if init_module.exists() and not _is_pkg_style_namespace(init_module):
                                stubs = init_module.with_suffix(".pyi")
                                return Package(module_name, init_module, stubs if stubs.exists() else None)
                            namespace_dirs.append(abs_path)

        if namespace_dirs:
            return NamespacePackage(module_name, namespace_dirs)

        raise ModuleNotFoundError(module_name)

tests/test_finder.py Outdated Show resolved Hide resolved
tests/test_finder.py Outdated Show resolved Hide resolved
tests/test_finder.py Outdated Show resolved Hide resolved
src/griffe/finder.py Outdated Show resolved Hide resolved
Copy link
Member

@pawamoy pawamoy left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK, LGTM, thanks so much 🚀

@pawamoy pawamoy merged commit bd37dfb into mkdocstrings:master Jan 18, 2023
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants